Skip to content

Reference user-defined types by id - #1966

Closed
leighmcculloch wants to merge 8 commits into
handcode-spec-refs-in-derivesfrom
scspec-udt-v2-id-field-9jgp9b
Closed

Reference user-defined types by id#1966
leighmcculloch wants to merge 8 commits into
handcode-spec-refs-in-derivesfrom
scspec-udt-v2-id-field-9jgp9b

Conversation

@leighmcculloch

@leighmcculloch leighmcculloch commented Jul 29, 2026

Copy link
Copy Markdown
Member

Warning

Stacked on #1965, so the diff here is only the id linking. Depends on unmerged and unreleased changes in:

A valid merge order for the whole stack:

  1. Add SCSpecTypeUDTV2 with a type id stellar-xdr#310 — add SCSpecTypeUDTV2 to the contract spec XDR
  2. Add borrowed Ref variants of generated types rs-stellar-xdr#560 — borrowed Ref variants of the generated types
  3. Add const XDR serialization on View types rs-stellar-xdr#562 — const XDR serialization on those Ref types
  4. Regenerate with SCSpecTypeUDTV2 rs-stellar-xdr#564 — regenerate with SCSpecTypeUDTV2
  5. Bump stellar-xdr for SCSpecTypeUDTV2 rs-soroban-env#1714 — re-pin to that regeneration
  6. Encode contract spec XDR at const evaluation time #1965 — encode contract spec XDR at const evaluation time
  7. this PR

What

Emit every reference to a user-defined type as ScSpecTypeUdtv2, which identifies the referenced type by an 8-byte id and nothing else, where before it was a ScSpecTypeUdt carrying only a name. Each user-defined type gains a spec_type_id() const fn returning its own id — the truncated SHA256 of its spec entry, over a canonical form with all contained ids zeroed — and a reference calls it on the referenced type, so the derive macro on a struct never has to know the id of a type it merely mentions.

Both the encoding and the hashing behind spec_type_id() happen at const evaluation time. Each type also renders its spec entry in canonical form as __SPEC_XDR_CANONICAL_REF, and spec_type_id() hashes that entry's const-encoded XDR. The id is therefore derived from the very bytes the contract emits, rather than from a second encoding of them made in the macro. sha2 has no const API, so the hashing uses the sha2-const crate, added as a dependency of soroban-sdk and re-exported for macro-generated code to reach.

Client generation in soroban-spec-rust resolves a reference's id back to a name, by matching it against the id of each user-defined type the spec defines, in a pass that runs before generation. A reference whose id matches no definition in the spec is an error rather than a silently wrong name.

Why

A spec's references were linked to their definitions by name alone, which is not enough to check that the two agree: a consumer reading UdtStruct in a function signature cannot tell whether it is the UdtStruct it holds a definition for, and a definition that changed shape while keeping its name looks identical to one that did not. An id derived from the referenced type's own definition makes that link verifiable. Carrying the id instead of the name, rather than alongside it, leaves one thing identifying the referenced type instead of two that can disagree.

Calling spec_type_id() on the referenced type rather than computing the id in the macro is what makes this work at all: the macro expanding struct A { b: B } sees only the tokens B, not B's definition, and it may live in another crate, so the id can only be resolved where both are visible, which is const evaluation. Zeroing contained ids in the hashed form keeps a type's identity independent of the ids it references, which is what makes it well-defined for types that reference each other or themselves — and at const evaluation time that zeroing also keeps a self- or mutually-recursive type from forming an evaluation cycle.

spec_type_id() is emitted for every user-defined type, including one whose spec export is disabled, because a reference from elsewhere still needs the id. Rendering a reference descends the Rust type in step with the spec type it was mapped from, so the call is emitted on the type as written, path qualification and all. Spec shaking's markers hash the same canonical form, so a marker computed in the macro before ids are resolved still matches the entry read back out of a built wasm.

Known limitations

The id is truncated to 8 bytes, so it identifies a type by content only to the extent 64 bits allow. Nothing consumes the id yet beyond resolving it back to a name; this only produces it.

Because a reference no longer carries a name, the 60-character limit that the reference's name field used to impose is now checked directly against the limit on the name of the type's own definition entry, which is where the limit actually lives.

Rendering each entry twice, once exported and once canonical, roughly doubles the generated spec tokens for a type. The extra entry is const-evaluated and unused at runtime, so it costs nothing in a built contract: rebuilding all 43 test wasms over this change leaves every contractspecv0 section byte-identical, and 42 of the 43 wasms byte-identical outright.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedsha2-const@​0.1.310010093100100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant